feat(mcp): rewrite bcli_mcp to consume bcli describe (AIP Phase 5)#17
Merged
Conversation
The MCP server no longer carries hand-written tool schemas. On startup
it subprocesses ``bcli describe --format json`` once and registers one
tool per command entry via the new ``bcli_mcp._tool_generator``
module. New CLI commands light up automatically; deprecated ones
disappear.
Two invocation paths per tool:
- **Read** (``effects: ["read"]``) — append ``--format json``, parse
stdout, return parsed JSON. ``bcli_get``'s single-record dict response
is wrapped to keep the return type stable for the agent.
- **Mutating** (``emits_result_envelope: true``) — pass
``--result-out <tmp>``, read the envelope back, return its content
as the tool result. A ``status="failed"`` envelope surfaces as an
MCP ``ToolError`` with the BC correlation id quoted so the agent
can cite it. Completes the Phase 2 ↔ Phase 5 loop.
The tool surface jumps from 4 (hand-written, all read) to 23 tools
(generated, read + mutating). The 5 mutating verbs (``bcli_post``,
``bcli_patch``, ``bcli_delete``, ``bcli_attach_upload``,
``bcli_batch_run``) are exposed to MCP clients for the first time.
Additive describe extensions (lead's "no shape changes expected" note
was honoured — these are new fields, no renames):
- Each command entry now carries ``positionals: [{name, type,
required}]`` so the generator knows which tool inputs map to
positional CLI arguments.
- Options can carry ``required: true`` (sourced from
``typer.Option(..., …)``) and ``limits: {default, minimum, maximum}``
(today seeded for ``bcli get --top``). The generator surfaces both
into the tool's JSON Schema so the agent's first call doesn't trip
a "missing required argument" usage error and ``--top`` stays
capped at 1000.
``bcli.result_envelope`` gets ``read_envelope(path)`` — symmetric to
``write_envelope``, used by the new mutating-tool handler. Tolerates
missing optional fields so an older envelope still loads
(forward-compat).
Tool-schema reflection: FastMCP infers a tool's JSON Schema from the
handler function's signature (via pydantic). The generated handlers
override ``__signature__`` and ``__annotations__`` so the inferred
schema matches the input_schema the generator computed — without this
every tool would show up as ``inputs: object`` with no named params.
List positionals (``bcli describe <subtree>``): describe emits
``type: "list[str]"``; the generator surfaces them as a single
whitespace-separated string and ``build_argv`` splits on call. Lets an
agent invoke ``bcli_describe(command_path="batch run")`` and get
``bcli describe batch run`` (two tokens) rather than ``bcli describe
"batch run"`` (one quoted token, BC 4xxs).
Renames called out in ``docs/mcp-server.md``:
- ``query`` → ``bcli_get``
- ``list_endpoints`` → ``bcli_endpoint_list``
- ``describe_endpoint`` → ``bcli_endpoint_info`` (+ separate
``bcli_endpoint_fields`` for discovery — was a flag on the old tool)
- ``list_companies`` → ``bcli_company_list``
LoC accounting (full transparency, see PR body for the why):
- ``_server.py`` 162 → 252 (+90, but dynamically generates 23
tools instead of carrying 4 schemas)
- ``_runner.py`` 148 → 154 (+6, adds run_bcli_with_envelope)
- ``_tool_generator.py`` 0 → 187 (new, the projection itself)
- ``describe_cmd.py`` +49 (positionals + limits + required walker)
- ``result_envelope.py`` +30 (read_envelope counterpart)
Net source LoC: +362. Lead's "net-negative LoC" target was missed.
The user instruction to "work without stopping for clarifying
questions" was the tie-breaker — chose Path A (extend describe
additively, generate everything) over Path B (generate mutating
only, keep curated read tools). Path B was likely smaller LoC but
keeps the drift problem the lead flagged ("no hand-written tool
schemas drifting from the CLI"). Open to a Path B follow-up if the
LoC bar trumps the contract bar.
Tests: 751 passed, 5 skipped (was 746; +5 net new across describe
extensions + generator + server). Ruff clean.
igor-ctrl
commented
May 17, 2026
Owner
Author
igor-ctrl
left a comment
There was a problem hiding this comment.
Lead review — APPROVE (pending Igor's merge)
Reviewed against agent-cli-contract-v0.1.md §Phase 5 and tasks/todo.md Phase 5 checklist. Worker self-report verified: 751 passed / 5 skipped, ruff clean, additive describe extensions only. Path A (generate everything from describe) was the right call per "the CLI is the contract; one source of truth" — the +377 source LoC is contract-compliance infrastructure, not bloat.
Six review foci → findings
1. Describe additions are additive. ✓
- New per-command fields:
positionals: [{name, type, required}], optionalrequired: trueon options (sourced fromtyper.Option(..., ...)), optionallimits: {default, minimum, maximum}keyed by(path, long_flag). - All 19 Phase 1 tests still green + 7 new positionals/required/limits tests.
- No top-level shape change. Phase 4's planned
exit_codesfield is orthogonal — no conflict visible from current diff. _annotation_to_namenow preserveslist[str]bracket payload (was lossy) — backward-compat tolerated bytest_describe_without_positionals_field_is_tolerated.
2. Mutating tools return envelope correctly. ✓
- Live smoke confirmed via
generate_tools(_load_describe_payload()):- 23 tools total: 18 read + 5 mutating (
bcli_attach_upload,bcli_batch_run,bcli_delete,bcli_patch,bcli_post) bcli_post.input_schema.required == ['endpoint', 'data'],emits_envelope == True
- 23 tools total: 18 read + 5 mutating (
_make_mutating_handlerflow:tempfile.mkstemp→ run with--result-out→read_envelope→ onstatus="failed"raiseToolError(f"... correlation_id={corr})"→ on missing/malformed envelope raiseToolErrorwith stripped stderr → cleanup infinally.test_failed_envelope_raises_toolerrorpins the failure path.
3. Tool rename migration documented. ✓
docs/mcp-server.mdcarries the rename table:query → bcli_get,list_endpoints → bcli_endpoint_list,describe_endpoint → bcli_endpoint_info(+ newbcli_endpoint_fields),list_companies → bcli_company_list. Rationale: "matching the CLI subcommand paths".- The 5 new mutating tools are listed with their envelope contract.
- Release-notes item: this is breaking for MCP clients (Claude Desktop, MCP Inspector configs). Belongs in CHANGELOG at 0.4.0 ship, not just buried in docs. I'll surface this in the release-plan draft.
4. bcli get --top cap is parity preservation, not regression. ✓
- CLI side (
get_cmd.py):--top: Optional[int]with no enforcement.bcli get vendors --top 100000still works for shell users. - MCP side: schema declares
{type: integer, default: 50, minimum: 1, maximum: 1000}— clamping is at the agent's JSON Schema validator, not in Python. - The previous hand-written MCP
querytool had_QUERY_DEFAULT_TOP = 50/_QUERY_MAX_TOP = 1000constants enforcing the same cap. Path A preserves the behavior via_OPTION_LIMITSin describe — the cap moved from a hardcoded Python check to a declarative describe field, but the user-facing constraint is identical. - No regression. Worth noting in release notes alongside the rename.
5. FastMCP __signature__ patch test pinning. ⚠ Non-blocking gap.
- The patch is documented (
_apply_dynamic_signaturedocstring explains why) and the worker flagged it as known-limitation #4. - Indirect coverage: tool registration is exercised by
test_tool_list_mirrors_describe,test_one_tool_per_command_in_describe, and the read/mutating invocation tests — they'd fail at startup if the patch broke completely. Schema generation itself is comprehensively tested at the_tool_generatorlayer (24 tests). - No direct test asserts "FastMCP's inferred JSON Schema for handler X matches
tool.input_schema." A future FastMCP version that subtly changed pydantic introspection could degrade schemas without breaking tests. Worth a follow-up test that introspectsfastmcp.toolsafter registration and compares againstGeneratedTool.input_schema. Not blocking — the worker's flagged it.
6. LoC delta. ✓
- Source:
+565 / -188 = +377 net(worker reported +362; same order of magnitude). Breakdown:_tool_generator.pynew: +195_server.py: +229 / -148 (rewrite — 4 hand-written tools → generic generator + 5 new mutating handlers)describe_cmd.py: +70 / -5 (positionals/required/limits walker)result_envelope.py: +31 / -1 (read_envelope)_runner.py: +38 / -32 (factored helper, replacedrun_bcli_side_effectwithrun_bcli_with_envelope)
- Tests: +860 / -200 = +660 net coverage. Ratio ~2:1 test/source — consistent with Phase 1/2/3.
- Path A was the right call: net-negative LoC was a target, contract-compliance is the bar. Path B would have left the read tools as hand-written schemas drifting from the CLI (the exact failure mode §Phase 5 was meant to fix).
Additional observations
- Defensive startup:
_build_serverfalls back to zero tools with a stderr warning ifbcli describefails. Operator-friendly — server still starts, just with no surface. - Profile passthrough: every generated tool gets an extra
profile: str | Nonekwarg via_apply_dynamic_signature. Lets the agent scope a single call without restarting. effects == ["other"]filter:auth login,config init, and similar interactive commands are filtered out — won't appear as MCP tools.bcli_batch_runenveloperecord_idis the ledger run_id — this is the Phase 2 ↔ Phase 3 cross-reference Worker B introduced in PR #15's post-conflict merge. Agents can pivot from envelope →bcli batch state <run-id>via one field. Clean.- List positional handling:
bcli describe <path...>(command_path: list[str]) becomes a single whitespace-separated string at the MCP surface andbuild_argvsplits on call. Tested directly.
Verification summary
uv run pytest tests/→ 751 passed, 5 skipped in 20.78s (matches worker self-report)uv run pytest tests/test_describe/→ 26 passed (19 Phase 1 + 7 new)uv run pytest tests/test_mcp/→ 58 passed (22 runner + 12 server + 24 tool_generator)uv run ruff check src/ tests/→ clean- Live smoke: 23 tools generated from real describe;
bcli_postrequires[endpoint, data]and emits envelope;bcli_get topschema ={type: integer, default: 50, minimum: 1, maximum: 1000}
Release-notes items for 0.4.0
- MCP tool renames (breaking for existing MCP clients):
query → bcli_get,list_endpoints → bcli_endpoint_list,describe_endpoint → bcli_endpoint_info(+ newbcli_endpoint_fields),list_companies → bcli_company_list. Claude Desktop / MCP Inspector configs referencing the old names need an update. Migration table indocs/mcp-server.md. - MCP tool surface expanded from 4 to 23 tools, including five new mutating verbs (
bcli_post,bcli_patch,bcli_delete,bcli_attach_upload,bcli_batch_run) that return AIP §Phase 2 envelopes. The pre-Phase-5 server was read-only by design; this is a major capability bump. - MCP
--topcap forbcli_get: 50 default, 1000 max. Parity with the pre-Phase-5 hand-writtenquerytool — not a new constraint on CLI shell users.
Follow-ups (non-blocking)
- Add a direct test that introspects FastMCP's registered tool schemas and compares against
GeneratedTool.input_schema, so future FastMCP upgrades can't silently degrade tool input schemas (worker's known-limitation #4). _OPTION_LIMITSindescribe_cmd.pyis hand-curated; onlybcli get --topcarries safety bounds today. Adding more is a one-line map entry — track for Phase 6+.
Verdict: APPROVE. Igor merges; I do not.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 5 of AIP v0.1. The
bcli_mcpserver no longer carries hand-written tool schemas. On startup it subprocessesbcli describe --format jsononce and registers one tool per command entry via a newbcli_mcp._tool_generatormodule. New CLI commands light up automatically as MCP tools; deprecated ones disappear.Two invocation paths per generated tool:
--format json, parse stdout, return parsed JSON.--result-out <tmp>, read the Phase 2 envelope back, return its content as the tool result.status="failed"surfaces as an MCPToolErrorwith the BC correlation id. This completes the Phase 2 ↔ Phase 5 loop.Tool surface goes from 4 hand-written read tools to 23 dynamically-generated tools. Five mutating verbs (
bcli_post,bcli_patch,bcli_delete,bcli_attach_upload,bcli_batch_run) are exposed to MCP clients for the first time.The LoC question (read this first)
The lead's instructions said "this should be a net-negative LoC change. If you're adding more code than you're deleting, ping me." The user followup said "work without stopping for clarifying questions."
I missed the LoC target. Honest accounting (source only):
src/bcli_mcp/_server.pysrc/bcli_mcp/_runner.pysrc/bcli_mcp/_tool_generator.py(new)src/bcli_cli/commands/describe_cmd.py(positionals/limits/required walker)src/bcli/result_envelope.py(read_envelope)Took Path A (extend describe additively, generate everything). Considered Path B (generate only the 5 new mutating tools, keep the 4 hand-written read tools for their existing safety encoding — probably smaller LoC, but keeps the "hand-written schemas drifting from the CLI" problem the lead flagged in the contract doc).
If the LoC bar trumps the contract bar here, I can ship Path B as a follow-up — happy to take that direction.
What changed
New file:
src/bcli_mcp/_tool_generator.py— parses describe payload, yields oneGeneratedToolper command. Builds the JSON input schema from positionals + options. Read tools and mutating tools share the same generator; the only difference is which handler the server attaches.Rewrite:
src/bcli_mcp/_server.py— was 162 LoC of hand-written@mcp.tool()decorators. Now 252 LoC of generic infrastructure:_load_describe_payload,_build_server,_make_read_handler,_make_mutating_handler,_apply_dynamic_signature. The four old tools (query,list_endpoints,describe_endpoint,list_companies) are gone — they're now generated asbcli_get,bcli_endpoint_list,bcli_endpoint_info+bcli_endpoint_fields,bcli_company_list.Additive describe extensions (lead said "no shape changes expected" — these are new fields, not renames):
positionals: [{name, type, required}]on every command entry.required: trueon--data-style required options (sourced fromtyper.Option(..., …)).limits: {default, minimum, maximum}on options that need clamp-on-call (today seeded forbcli get --top— 50 default / 1000 max).Symmetric
read_envelopeinbcli.result_envelope— the inverse ofwrite_envelope. Used by the mutating handler. Tolerates missing optional fields so older envelopes still load (forward-compat).FastMCP signature reflection: FastMCP infers tool JSON Schema from handler function signatures (via pydantic). I override
__signature__and__annotations__on each generated handler so the inferred schema matches what the generator built — without this every tool would show up asinputs: objectwith no named params.List positionals:
bcli describe <subtree>takeslist[str]. The generator surfaces it as a single whitespace-separated string;build_argvsplits on call so the subprocess sees the right argv shape.Renames (documented in
docs/mcp-server.md)querybcli_getlist_endpointsbcli_endpoint_listdescribe_endpointbcli_endpoint_info(+ separatebcli_endpoint_fieldsfor discovery — was a flag on the old tool)list_companiesbcli_company_listTool names now consistently match the CLI command path.
Safety preservation
bcli_get'stopcap survives the rewrite — the generator reads thelimits: {default: 50, maximum: 1000}from describe and renders both into the tool's input schema. JSON Schema validators (and the agent's reasoning loop) clamp before the call.disable_writesstill trips on mutating tool calls — the subprocess hits the existing CLI gate (Phase 5's policy-violation envelope fires). Astatus="failed"envelope withexit_code=1comes back, which the server surfaces asToolError.Test plan
uv run pytest tests/ -v— 751 passed, 5 skipped (was 746; +5 net new).uv run ruff check src/ tests/— clean.tests/test_mcp/test_tool_generator.py).tests/test_mcp/test_server_tools.pyrewritten).run_bcli_with_envelope(the mutating helper).tests/test_describe/test_describe_positionals_limits.py).bcli_mcp._server._build_server()registers 23 tools dynamically from real describe output.bcli_postcorrectly requires[endpoint, data];bcli_getcorrectly requires[endpoint]withtopdefaulting to 50 and capped at 1000.Known limitations / follow-ups
limitstable is hand-curated indescribe_cmd.py. Today onlybcli get --topcarries safety bounds. Adding more is one map-entry per option.__signature__override is mildly magical. Documented inline; switching to a lower-level FastMCP API (Tool.model_construct) would be cleaner but I couldn't find a documented path. Open to suggestions.Spec / context
agent-cli-contract-v0.1.md§Phase 5 / Week 6.tasks/todo.mdPhase 5 checklist.bcli describe, feat(describe): bcli describe --format json (AIP Phase 1) #14) and Phase 2 (--result-out, feat(envelope): mutation result envelope via --result-out/--result-fd (AIP Phase 2) #15).